// (c) Copyright 2013-2014 Stephen Gold <sgold@sonic.net>
// Distributed under the terms of the GNU General Public License

/*
 This file is part of the Bats Game.

 The Bats Game is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by the
 Free Software Foundation, either version 3 of the License, or (at your
 option) any later version.

 The Bats Game is distributed in the hope that it will be useful, but
 WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 for more details.

 You should have received a copy of the GNU General Public License
 along with the Bats Game.  If not, see <http://www.gnu.org/licenses/>.
 */
package bats.scene;

import bats.Assets;
import com.jme3.asset.AssetManager;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.light.Light;
import com.jme3.light.LightList;
import com.jme3.light.PointLight;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.renderer.ViewPort;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.shadow.AbstractShadowRenderer;
import com.jme3.shadow.DirectionalLightShadowRenderer;
import com.jme3.shadow.EdgeFilteringMode;
import java.util.ArrayList;
import java.util.Map;
import java.util.TreeMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import jme3utilities.ViewPortListener;
import jme3utilities.sky.SkyControl;
import jme3utilities.sky.Updater;

/**
 * A scene component to manage the lighting environment: light sources, shadow
 * renderers, sky dome, and background.
 *
 * @author Stephen Gold <sgold@sonic.net>
 */
public class Lighting
        implements ViewPortListener {
    // *************************************************************************
    // constants

    /**
     * background color for the VOID preset: unlike a true sky, this color will
     * not be reflected in simple water
     */
    final private static ColorRGBA voidBackgroundColor = ColorRGBA.Gray;
    /**
     * main light color for the VOID preset
     */
    final private static ColorRGBA voidLightColor = ColorRGBA.White;
    /**
     * shadow map repartition constant (&le;1, &ge;0)
     */
    final private static float lambda = 0.6f;
    /**
     * number of shadow maps rendered per light (&gt;0)
     */
    final private static int nbSplits = 1;
    /**
     * width and height of rendered shadow maps (pixels per side, &gt;0)
     */
    final private static int shadowMapSize = 4_096;
    /**
     * message logger for this class
     */
    final private static Logger logger =
            Logger.getLogger(Lighting.class.getName());
    /**
     * main light direction for the VOID preset
     */
    final private static Vector3f voidLightDirection =
            new Vector3f(1f, -1f, 1f).normalizeLocal();
    // *************************************************************************
    // fields
    /**
     * view ports whose background colors are updated by this control - not
     * serialized
     */
    final private ArrayList<ViewPort> viewPorts = new ArrayList<>();
    /**
     * the scene's shadow rendering status: false means no shadows
     */
    private boolean shadowsEnabled = false;
    /**
     * current preset
     */
    private LightingPreset preset = null;
    /**
     * map light + view port combinations to shadow renderers
     */
    final private Map<String, AbstractShadowRenderer> renderers =
            new TreeMap<>();
    /**
     * which scene: set by constructor
     */
    final private SceneNode scene;
    /**
     * the scene's sky
     */
    private SkyControl sky = null;
    // *************************************************************************
    // constructors

    /**
     * Instantiate for a specified scene. The first method invoked should be
     * initialize().
     *
     * @param scene which scene will contain these lights (not null)
     */
    Lighting(SceneNode scene) {
        assert scene != null;
        this.scene = scene;
    }
    // *************************************************************************
    // new methods exposed

    /**
     * Add a point light to the scene.
     *
     * @param light (not null)
     * @param source the associated spatial (not null)
     */
    void addLight(PointLight light, Spatial source) {
        assert light != null;
        assert source != null;
        /*
         * Make sure the light has the same name as its associated spatial.
         */
        String name = source.getName();
        light.setName(name);

        addLightNoShadows(light);
        if (shadowsEnabled) {
            enableShadows(light);
        }
    }

    /**
     * Test whether shadows are enabled.
     *
     * @return true if shadows are enabled, otherwise false
     */
    public boolean areShadowsEnabled() {
        return shadowsEnabled;
    }

    /**
     * Delete any light associated with a spatial.
     *
     * @param spatial which spatial (not null)
     */
    void deleteSpatialLights(Spatial spatial) {
        assert spatial != null;

        Light associatedLight = find(spatial);
        if (associatedLight == null) {
            return;
        }

        if (shadowsEnabled) {
            disableShadows(associatedLight);
        }
        scene.getWorld().getNode().removeLight(associatedLight);
    }

    /**
     * Find the light (if any) associated with a spatial.
     *
     * @param spatial (not null)
     * @return a pre-existing instance, or null if not found
     */
    public Light find(Spatial spatial) {
        /*
         * If there's an associated light, it will have the same name.
         */
        String name = spatial.getName();
        Light result = findLight(name);
        return result;
    }

    /**
     * Read the current lighting preset.
     *
     * @return preset value (not null)
     */
    public LightingPreset getPreset() {
        if (preset == null) {
            return LightingPreset.VOID;
        } else {
            return preset;
        }
    }

    /**
     * Access the sky.
     *
     * @return the pre-existing object
     */
    public SkyControl getSky() {
        assert sky != null;
        return sky;
    }

    /**
     * Initialize this instance to the VOID preset.
     *
     * @param cloudFlattening the oblateness (ellipticity) of the dome with the
     * clouds: 0=no flattening (hemisphere), 1=maximum flattening
     */
    void initialize(float cloudFlattening) {
        assert !shadowsEnabled;
        assert getLights().size() == 0 : getLights();

        preset = LightingPreset.VOID;
        /*
         * Initialize the list of view ports.
         */
        assert viewPorts.isEmpty();
        ViewPort viewPort = scene.getViewPort();
        viewPorts.add(viewPort);
        /*
         * Set the background color.
         */
        viewPort.setBackgroundColor(voidBackgroundColor);
        /*
         * Create the main light and add it to the scene.
         */
        DirectionalLight mainLight = new DirectionalLight();
        mainLight.setColor(voidLightColor);
        mainLight.setDirection(voidLightDirection);
        mainLight.setName("main");
        addLightNoShadows(mainLight);
        /*
         * Create the ambient light and add it to the scene.
         * Its color and intensity are tied to the main light
         * by means of update().
         */
        AmbientLight ambientLight = new AmbientLight();
        ambientLight.setColor(voidLightColor);
        ambientLight.setName("ambient");
        addLightNoShadows(ambientLight);
        /*
         * Create the sky, and add it to the scene without enabling it.
         */
        AssetManager assetManager = Assets.getManager();
        Camera camera = scene.getApplication().getCamera();
        boolean starMotion = false;
        boolean bottomDome = false;
        sky = new SkyControl(assetManager, camera, cloudFlattening, starMotion,
                bottomDome);
        scene.getWater().addSkyControl(sky);

        Updater updater = sky.getUpdater();
        updater.addViewPort(viewPort);
        if (scene.isEnabled()) {
            sky.setCloudRate(1f);
        } else {
            sky.setCloudRate(0f);
        }
        updater.setAmbientLight(ambientLight);
        updater.setMainLight(mainLight);
    }

    /**
     * Process an action which has just begun.
     *
     * @param words action string split into an array of words (not null)
     * @return true if the action was handled, false if it wasn't handled
     */
    boolean processAction(String[] words) {
        String verb = words[0];
        switch (verb) {
            case "select":
                if (words.length == 3 && "lighting".equals(words[1])) {
                    applyPreset(words[2]);
                    return true;
                }
                break;

            case "toggle":
                if (words.length == 2 && "shadows".equals(words[1])) {
                    toggleShadows();
                    return true;
                }
                break;
        }
        return false; // not handled
    }

    /**
     * Apply a lighting preset.
     *
     * @param newPreset (not null)
     */
    void setPreset(LightingPreset newPreset) {
        assert newPreset != null;
        /*
         * Record the change.
         */
        preset = newPreset;
        /*
         * Update the sky, background color, and lights
         * according to the new setting.
         */
        if (preset == LightingPreset.VOID) {
            sky.setEnabled(false);
            for (ViewPort viewPort : viewPorts) {
                viewPort.setBackgroundColor(voidBackgroundColor);
            }
            DirectionalLight mainLight = (DirectionalLight) findLight("main");
            mainLight.setColor(voidLightColor);
            mainLight.setDirection(voidLightDirection);
            AmbientLight ambientLight = (AmbientLight) findLight("ambient");
            ambientLight.setColor(voidLightColor);
            return;
        }

        sky.setEnabled(true);

        float cloudiness = preset.cloudiness();
        sky.setCloudiness(cloudiness);

        float hour = preset.hour();
        sky.getSunAndStars().setHour(hour);
    }

    /**
     * Enable or disable shadow rendering.
     *
     * @param newState if true, enable shadows; if false, disable them
     */
    public void setShadowsEnabled(boolean newState) {
        if (newState) {
            enableShadows();
        } else {
            disableShadows();
        }
    }
    // *************************************************************************
    // ViewPortListener methods

    /**
     * Callback when a view port is created.
     *
     * @param viewPort (not null)
     */
    @Override
    public void addViewPort(ViewPort viewPort) {
        if (viewPort == null) {
            throw new NullPointerException("view port should not be null");
        }

        if (shadowsEnabled) {
            enableShadows(viewPort);
        }
        sky.getUpdater().addViewPort(viewPort);
        viewPorts.add(viewPort);
    }

    /**
     * Callback when a view port is removed.
     *
     * @param viewPort (not null)
     */
    @Override
    public void removeViewPort(ViewPort viewPort) {
        if (viewPort == null) {
            throw new NullPointerException("view port should not be null");
        }

        if (shadowsEnabled) {
            disableShadows(viewPort);
        }
        sky.getUpdater().removeViewPort(viewPort);
        viewPorts.remove(viewPort);
    }
    // *************************************************************************
    // private methods

    /**
     * Add a light's shadow renderer to a view port.
     *
     * @param light the light associated with the filter (not null)
     * @param renderer the shadow renderer to apply (not null)
     * @param viewPort (not null)
     */
    private void addRenderer(Light light, AbstractShadowRenderer renderer,
            ViewPort viewPort) {
        assert renderer != null;

        viewPort.addProcessor(renderer);
        /*
         * Save a reference to the post-processor.
         */
        String key = generateKey(light, viewPort);
        AbstractShadowRenderer oldRenderer = renderers.put(key, renderer);
        assert oldRenderer == null : oldRenderer;
    }

    /**
     * Add a light with no shadow processors.
     *
     * @param light which light (not null)
     */
    private void addLightNoShadows(Light light) {
        assert light != null;

        Node worldNode = scene.getWorld().getNode();
        worldNode.addLight(light);
    }

    /**
     * Apply a preset based on its description.
     *
     * @param newPresetDescription (not null)
     */
    private void applyPreset(String newPresetDescription) {
        assert newPresetDescription != null;

        LightingPreset newPreset =
                LightingPreset.fromDescription(newPresetDescription);
        setPreset(newPreset);
    }

    /**
     * Stop rendering shadows for the entire scene.
     */
    private void disableShadows() {
        if (!shadowsEnabled) {
            return;
        }

        LightList lights = getLights();
        for (Light light : lights) {
            disableShadows(light);
        }
        shadowsEnabled = false;
    }

    /**
     * Stop rendering shadows for a specified light.
     *
     * @param light (not null)
     */
    private void disableShadows(Light light) {
        assert light != null;

        for (ViewPort viewPort : viewPorts) {
            disableShadows(light, viewPort);
        }
    }

    /**
     * Stop rendering shadows on a specified view port.
     *
     * @param viewPort (not null)
     */
    private void disableShadows(ViewPort viewPort) {
        assert viewPort != null;

        LightList lights = getLights();
        for (Light light : lights) {
            disableShadows(light, viewPort);
        }
    }

    /**
     * Stop rendering shadows on a specified view port for a specified light.
     *
     * @param light (not null)
     * @param viewPort (not null)
     */
    private void disableShadows(Light light, ViewPort viewPort) {
        String lightName = light.getName();
        String viewPortName = viewPort.getName();
        String key = String.format("%s/%s", lightName, viewPortName);
        AbstractShadowRenderer renderer = renderers.remove(key);
        if (renderer != null) {
            /*
             * Ambient lights (for instance) don't generate shadows.
             */
            viewPort.removeProcessor(renderer);
            sky.getUpdater().removeShadowRenderer(renderer);
        }
    }

    /**
     * Begin rendering shadows for the entire scene.
     */
    private void enableShadows() {
        logger.log(Level.INFO, "{0}", scene.toString());

        if (shadowsEnabled) { // already enabled
            return;
        }
        /*
         * Add a renderer to each view port for each light in the scene.
         */
        LightList lights = getLights();
        for (Light light : lights) {
            enableShadows(light);
        }
        shadowsEnabled = true;
    }

    /**
     * Begin rendering shadows for a specified light.
     *
     * @param light which light (not null)
     */
    private void enableShadows(Light light) {
        for (ViewPort viewPort : viewPorts) {
            enableShadows(light, viewPort);
        }
    }

    /**
     * Begin rendering shadows on a specified view port.
     *
     * @param viewPort (not null)
     */
    private void enableShadows(ViewPort viewPort) {
        assert viewPort != null;

        LightList lights = getLights();
        for (Light light : lights) {
            enableShadows(light, viewPort);
        }
    }

    /**
     * Begin rendering shadows on a specified view port for a specified light.
     *
     * @param light which light (not null)
     * @param viewPort which view port (not null)
     */
    private void enableShadows(Light light, ViewPort viewPort) {
        if (light instanceof DirectionalLight) {
            AssetManager assetManager = Assets.getManager();
            DirectionalLight directionalLight = (DirectionalLight) light;

            DirectionalLightShadowRenderer renderer =
                    new DirectionalLightShadowRenderer(
                    assetManager, shadowMapSize, nbSplits);
            /*
             * Apply 4x4 percentage-closer filter to shadow edges
             * in order to mitigate aliasing when light hits
             * a polygon edge-on, or nearly so.
             */
            renderer.setEdgeFilteringMode(EdgeFilteringMode.PCF4);
            renderer.setLambda(lambda);
            renderer.setLight(directionalLight);
            addRenderer(light, renderer, viewPort);
            if (viewPort == scene.getViewPort()) {
                sky.getUpdater().addShadowRenderer(renderer);
            }

        } else if (light instanceof PointLight) {
            /* do nothing -- not the main light */
        } else {
            assert light instanceof AmbientLight;
        }
    }

    /**
     * Access a light by name.
     *
     * @param name (not null)
     * @return a pre-existing light, or null if not found
     */
    private Light findLight(String name) {
        assert name != null;

        LightList lights = getLights();
        for (Light light : lights) {
            if (name.equals(light.getName())) {
                return light;
            }
        }
        return null;
    }

    /**
     * Generate a key for a specified combination of light and view port.
     *
     * @param light (not null)
     * @param viewPort (not null)
     */
    private String generateKey(Light light, ViewPort viewPort) {
        String lightName = light.toString();
        String viewPortName = viewPort.toString();
        String result = String.format("%s/%s", lightName, viewPortName);

        return result;
    }

    /**
     * Access the list of all lights in the scene.
     *
     * @return pre-existing instance
     */
    private LightList getLights() {
        LightList result = scene.getWorld().getNode().getLocalLightList();
        return result;
    }

    /**
     * Toggle the shadow rendering status.
     */
    private void toggleShadows() {
        setShadowsEnabled(!shadowsEnabled);
    }
}